iT邦幫忙

2026 iThome 鐵人賽

DAY 12
0
AI Engineering

Backend 工程師的 Azure GenAI 實戰系列 第 12

Day 12:切分、Embedding 與 Index Schema——四個改不動的決定,四張不一樣大的帳單

  • 分享至 

  • xImage
  •  

Day 11 把 RAG 拆成兩條 pipeline,並說 indexing 那條的失敗會「污染之後每一次查詢」。今天要動工的就是這條線。

「決定改不了」這個說法太籠統,而且不完全是真的。真正會咬人的是:這些決定改起來的代價差很多,而直覺會把它們算成同一筆帳。「反正都要整份重跑一次吧」是最貴的一種誤會。

這篇不教你怎麼用某個切分套件。它要回答的是:這四個決定各自被什麼限制綁住、我在這個 lab 選了什麼、以及選錯要付多少。讀完你會知道為什麼我沒有用 token 當切分單位——即使那才是直覺的選擇。

四個決定,四張不一樣大的帳單

先把帳算清楚。這四件事都不能隨手改,但「不能隨手改」底下藏著三種完全不同的代價:

決定 改動代價 為什麼是這個代價
切分邊界(chunk 大小、overlap、切在哪)、送進 embedding 的文字 重切 + 重算全部 embedding + 重灌 chunk 是被向量化的單位,邊界動了,既有向量就不再對應任何一段原文
embedding 模型或維度 重算全部 embedding + 重建 index 不同模型的向量空間不可比較;維度不同時,連欄位都塞不進去
新增 metadata 欄位 不必重建:送一次 schema update 就好 官方明列在「不需重建」清單裡。既有文件的新欄位是 null,下一輪 indexing 才填進去
既有欄位的型別、屬性、analyzer drop + rebuild + 重灌,但向量可以不用重算 欄位屬性建立後不可變更。不過只要向量空間沒變、向量還留在服務端,這次重建就不必重付 embedding

第三列是最容易記錯的一格。「改 schema」聽起來就該是大工程,但官方的「不需重建」清單裡明明白白寫著「新增欄位」(查核 2026-07)。同一份清單上還有「把既有欄位的 retrievable 打開」、「新增 analyzer 定義」等等。

要 drop + rebuild 的是另一批:刪欄位、改欄位名稱或型別、改 searchablefilterablesortablefacetable、把 analyzer 指派到既有欄位。

第四列講的是一件之後每次改 schema 都會用到的事:「重建 index」和「重付一次 embedding」是兩筆可以拆開的帳。 下面 stored: true 那一節就是在買這件事。

為什麼用字元,不用 token

切分要有單位。最自然的答案是 token——因為模型的輸入上限是用 token 算的,計費也是用 token 算的。用字元切,聽起來像是拿錯尺。

順帶一提,這個上限本身官方文件就對不起來:embeddings how-to 寫 8,192,chunk documents 寫 8,191,兩頁都是 2026-07 查核時的現況。

差一個 token 不影響下面的結論,這裡的做法離上限還有三倍距離。但既然查到了就照實記著,不挑一個當定論。

但引一個 tokenizer 進來,代價比看起來大。Day 9 把 token 這件事定成「計帳不估算」,而那個決定其實是三層,不是一句話:

  • 本地 tokenizer 算出來的是估算值
  • API 回應裡的 usage 是 provider 回報的單次請求實際用量,拿來記帳、歸因、擋預算。
  • 真正的帳務權威是 Azure 的發票與 Cost Management。

在 indexing 這裡引 tiktoken,等於在同一個系統裡養出第二套 token 真相——一套來自本地函式庫、一套來自 API 回應,而它們沒有義務永遠一致。多一個相依套件事小,多一個會漂移的真相事大。

所以本系列的選擇是:切分用字元計算,把字元當成 token 上限的代理。這個選擇要成立,得先知道這把尺有多粗,所以我實測了它。

兩份樣本,各剛好 2,000 字元(就是設定裡的 chunk_max_chars),直接打 embeddings endpoint,讀回傳的 usage(實測 2026-07,text-embedding-3-small v1、GlobalStandard、japaneast):

english: chars=2000 tokens=377  chars_per_token=5.31
chinese: chars=2000 tokens=2572 chars_per_token=0.78
字元 prompt_tokens 每 token 幾個字元
英文 2,000 377 5.31
中文 2,000 2,572 0.78

先講這兩個數字能證明什麼、不能證明什麼。它們是兩個合成樣本(一句英文重複到 2,000 字元、一句中文重複到 2,000 字元),不是語料普查,也不是最壞情況搜尋。所以「中文比英文密 6.8 倍」這句話的有效範圍就是這兩個樣本。標點密度、數字、混語言、罕見字,比例都會跑掉。

不過有一件事它們確實證明了:那條被到處引用的「大約四個字元一個 token」,用在中文上,方向根本是相反的。這裡的中文是 1.29 個 token 吃掉一個字元。你若憑那句 rule of thumb 抓中文語料的 chunk 大小,量級就錯了。

https://ithelp.ithome.com.tw/upload/images/20260812/20168288ztdTeqHzCZ.png
忍喵:抄這個數字進筆記本可以,但別把它當常數,它是一個合成樣本量出來的。真正該記住的是方向:中文比英文吃 token,不是省 token。

那字元到底夠不夠保守?誠實的答案是:在這份語料上夠,但那不是保證。 2,000 字元的中文樣本是 2,572 tokens,離 8,192 還有三倍餘裕(8,191 還是 8,192 在這個距離下沒有差別)。

可是 token 化的密度沒有上界可言。官方文件給的做法是「送出前檢查每一筆輸入」。我沒有做那件事,因為做它就要引 tokenizer——也就是上面剛拒絕掉的那個東西。

所以這裡要說清楚我買了什麼、賣了什麼:chunk_max_chars = 2000 是一條量測出來的語料政策,不是一道保證不超限的閘門。萬一真有一筆 chunk 超過上限,發現它的方式是 embeddings endpoint 回一個 400,而不是我在送出前擋下來。這是一個已知的缺口。寫出來,總比讓你以為那把粗尺很準好。

結構優先,切不動才降級

決定了單位,接下來是切在哪。

一份 Markdown 文件本身就有結構,而作者寫下的標題就是最好的語意邊界。所以第一順位是按標題章節切:一個章節一個 chunk。這麼做的附帶好處是引用有名字——Day 14 要讓答案附來源時,「退貨政策 > 例外情況」比「第 7 塊」對讀者有意義得多。

章節塞不下才降級,一階一階往下退:

  1. 段落邊界:空行,作者自己標的意思單位
  2. 句子邊界:退到這裡已經在切開一個完整想法
  3. 硬切:照字元數切斷,唯一沒有語意的邊界

值得多寫兩句的是句子邊界。判斷句尾不能只認 .!?,中文的句號、驚嘆號、問號(U+3002、U+FF01、U+FF1F)一樣是終止符;更關鍵的是不能假設有空白。英文可以靠空白分詞,中文沒有詞間空白——一個以空白為前提的切分器,會把整段中文當成一個切不開的長 token,然後直接掉到硬切。這個 bug 不會報錯,只會讓中文永遠拿不到句子邊界。

Overlap 的規則同樣要講死,不然它會變成一個模糊的「大概重疊一點」:只在同一章節內重疊(跨標題不重疊,否則 chunk 會帶著不屬於它那節的內容)、對齊句子邊界、而且重疊的部分算進預算——重疊不能把 chunk 撐爆上限。

還有一條反直覺的規則:沒有自己內文的標題不產生 chunk。像「退款期限」這種下面只掛著更深標題、自己沒有半句話的節點,直覺會想留一個「章節層級」的 chunk。但它的 content 是空的——那會是一次 embedding 呼叫、一筆 index 資料,唯一的訊號只有標題本身;萬一它在檢索中勝出,讀者看到的引用是一片空白。而且這個標題並沒有消失。它還留在所有子章節的 heading_path 裡(「退貨政策 > 退款期限 > 一般訂單」),照樣搜得到。

一份文件散開成幾筆 search document,大概長這樣:

一份 Markdown 文件切成七個 chunk 的對應圖

注意左邊那條虛線:「退款期限」自己沒有內文,所以它不產生 chunk,但它的名字活在兩個子章節的 heading_path 裡。右邊的「例外情況」則是超過預算被拆成三塊的那種,00040005 各自帶著前一塊的句尾。整份文件的 chunk 都共用同一個 parent_id,之後要整份替換掉它,靠的就是這個欄位。

這條規則有個推到底的角落:如果整份文件只有標題、沒有任何內文,那它一個 chunk 都產不出來。切分函式在這種情況直接報錯,不回一份空清單。理由是下游:空清單會一路流進替換流程,而那道「全部成功才准刪舊的」閘門遇到空集合是不會開的,舊內容於是永遠留在 index 裡,而且沒有任何一步失敗。與其讓它安靜地卡住,不如在還沒動到 index 之前就說「這份文件沒有可索引的內容」。至於「這份文件的內容真的該全部刪掉」,那是另一件事,得由呼叫端明講,不能靠一個空陣列去暗示。

被向量化的文字,和讀者看到的文字

每個 chunk 送去 embedding 的文字,前面會接上完整的標題路徑:

退貨政策 > 退款期限 > 促銷商品

促銷商品自出貨日起七日內可退。

content 欄位只有後面那句話,那才是之後要拿來當引用顯示給讀者的。

差別在於,一段內文脫離標題往往是無主的。「七日內可退」這句話單獨向量化,語意上跟「退貨」「促銷」的關聯很弱;把標題路徑接進去,這個 chunk 才知道自己在講什麼。但你不會希望每則引用都拖著一串麵包屑給讀者看。所以兩者刻意分開:embedding input 加料,citation text 保持乾淨

Index schema 是程式碼,而且 CI 會盯著它

index schema 用 Python 定義,匯出成 JSON 進版控,CI 檢查有沒有漂移——跟這個 repo 既有的 OpenAPI 匯出同一個形狀。schema 改了但沒重新匯出,CI 就紅。

schema 裡最值得講的是向量欄位的兩個屬性:

"stored": True,        # 服務端保留原始向量
"retrievable": False,  # 但查詢結果不回傳它

retrievable: False 是為了不要在每次查詢回應裡塞 1,536 個浮點數。而 stored: True 這個決定要慎重,因為它是不可逆的:設成 false 之後改不回來。

官方文件把話說死了。之後若用 partial merge 更新文件卻沒有帶上完整向量,向量會遺失,而且「不會有錯誤或警告」(查核 2026-07,vector storage options)。一個不會報錯的資料遺失路徑,值得多花那點儲存費避開。

換來的好處比省下的儲存空間重要得多。向量留在服務端,重建 index 和重新付 embedding 的錢就是兩件可以拆開的事:你可以把向量讀出來、灌進新的 index,而不必把整份語料重新丟給 embedding API 算一次。這就是上面那張表第四列的來源:欄位屬性改不動、非 drop + rebuild 不可時,只要向量空間沒變,這次重建就不必重付 embedding。

不過「讀出來」這一步有個前提得寫清楚,因為這份 schema 剛好把它關掉了:retrievable: false 的欄位查詢不會回傳,所以匯出向量的順序是:

  1. 先把 retrievable 改成 true。這個屬性改動就列在剛才那份「不需重建」清單裡,不用 drop index。
  2. 查詢把向量讀出來。
  3. 建新 index、灌進去。
  4. 想的話再把查詢面的設定改回 false

少了第一步,你會遇到一個很難查的狀況:向量明明還在服務端,你卻拿不出來。

https://ithelp.ithome.com.tw/upload/images/20260812/20168288jcZqgQniWP.png
忍喵:省下的不是儲存費,是「schema 每改一次就得重新 embedding 一輪」那筆帳。語料越大越痛。

兩種失敗,兩張表

這一節最容易做錯,而且做錯了不會馬上壞。

Indexing 這條線上有兩個完全不同的失敗面:

Embedding 失敗 Search 索引失敗
發生時機 寫入之前 上傳之後
粒度 整個 request 一個錯誤物件 每份文件各自有 statusstatusCode
能不能歸咎到單一 chunk 不能

關鍵在第三列。embeddings endpoint 的 400 是 request 層級的:一批 36 筆輸入被拒絕,回應裡沒有 per-input 的索引或狀態告訴你是哪一筆有問題(查核 2026-07,官方未見明文記載回應 schema 的頁面,此處為實測觀察,非官方保證)。而 Azure AI Search 的批次上傳會逐份回報結果,責任歸屬是明確的。

所以這兩種失敗不能共用一張分類表。把 embedding 的 400 丟進「逐份文件」的分類器,你得憑空捏造一個 per-document 狀態,或者把整批 chunk 的資訊丟掉——兩種都是拿錯的抽象去套。在 lab 裡它們是兩個模組、兩套詞彙,這個分界是刻意維持的。

至於替換文件時的順序:先上傳新的 chunk,全部確認成功了,才刪舊的。而且那道「確認」是 fail-closed 的——期望的 key 全部出現、沒有重複、每一筆都成功,才准進刪除。少一個條件就不刪。

代價是有一段時間新舊 chunk 並存,同一份文件的內容會重複出現在檢索結果裡。這是我選的那一邊:寧可重複,不要缺漏。反過來先刪再傳,中間任何一個環節失敗,這份文件就從 index 裡消失了,而且沒人會發現。

但這道閘門很容易被讀成比它實際更強的保證,所以界線要畫清楚。上傳回 200 的意思是「所有項目都已經持久化,並且開始建索引」。建索引是背景工作,新文件通常要幾秒之後才查得到(查核 2026-07,Responses)。逐份 200/201 證明的是「寫進去了」,不是「查得到了」。確認全部成功就立刻刪舊 chunk,還是可能開出一個短暫的查詢空窗。

先上傳後刪除真正買到的是另一件事:不會因為刪除成功、上傳失敗而永久掉內容。 而且這個保證有個前提:這份文件本來就有一版在 index 裡。第一次索引一份新文件如果失敗,沒有舊版可以退,那份文件就是查不到;這一點寫在 lab 的 FSM 裡,不在這道閘門的能力範圍內。

這個並存視窗也不是 Azure AI Search 的天性,它是「單一 index、不做別名切換」這個策略的後果。真要消除它,正式環境的做法是 index alias:建新 index、灌完、把別名指過去。

那也不是瞬間切換。別名更新最久要 10 秒才傳播完,這段時間請求可能打到新的、也可能打到舊的(兩邊各自都是完整的一代),官方要求刪掉舊 index 前至少等 10 秒(查核 2026-07,Update an alias)。這個 lab 沒走這條路,因為它會把這一天的重點從「切分決策」拉去講部署拓樸。

誠實的邊界

幾件這天沒做完的事,先講清楚比之後被讀者抓到好:

  • 這一天沒有真的寫進 Azure AI Search。 交付的是失敗處理的合約與 schema,上傳實作在 Day 13。
  • effective_date 的序列化還沒收口。 程式裡是 date,schema 欄位是 Edm.DateTimeOffset,中間的轉換還沒寫——因為還沒有任何程式把 chunk 轉成 index 文件。這是 Day 13 要補的洞,記在設計文件裡而不是假裝不存在。
  • 切分參數還沒接到設定。 chunk_max_charschunk_overlap_chars 存在也有驗證,但目前切分函式是吃明確參數的,還沒有呼叫端把設定值餵進去。
  • analyzer 選了 en.microsoft,這對中文語料是錯的。 這個 lab 的範例語料是英文,所以現在沒問題;但 analyzer 屬性是要 drop + rebuild 才能改的那一類。你若拿這份 schema 去索引中文文件,記得先改這一格。
  • 這個切分器只認得 Markdown 的一個子集。 它認頂格####### 標題、空行段落,以及 fenced code block(```~~~,裡面的 # 不會被當成標題)。
  • 不認得的那幾種,代價差很多。 底線式標題會讓整份文件塌成一節;縮排的標題會被當成內文默默吞掉(CommonMark 允許標題前最多三個空白,這個模組的 fence 判斷也照做了,但標題判斷要求頂格——同一支檔案裡兩套規則打架,這是疤不是設計)。
  • 會真的弄髒輸出的有兩種,其中一種特別彆扭。 一種是 HTML block:<div> 裡的 # 某行 會被當成真標題,長出一條假麵包屑。另一種是 info string 裡帶了反引號的 code fence,例如 ```js `x`——CommonMark 規定這種行不算開 fence,於是它下面的 # 就是一個貨真價實的標題。切分器完全照規格走,而照規格走的結果,恰好重現了它當初就是為了修掉的那個 bug。我沒有為此偏離 CommonMark:為了掩蓋一種寫法錯誤而自訂規則,是拿一個看得見的意外去換一個看不見的意外。它現在有測試釘著,是已知的邊界,不是埋著的地雷。
  • fenced code block 這一條,是外部 review 抓出來的。 補之前,shell 註解裡的 # 會憑空長出一層假標題,還會把 code fence 從中間切成兩半,而 289 個測試全綠。範例語料裡剛好沒有任何 code block,所以它一直沒被踩到——測試蓋不到,語料也剛好避開。
  • 空白會被動到,而且沒有任何一條路徑逐位元組保留原文。 這件事分三層:每個章節在切之前,整段的前後空白就先被 strip 掉了(所以章節第一行的縮排一定不見);沒超過預算的章節,內部空白就是作者寫的樣子;一旦超長走上拆分路徑,每個段落的前後空白也會被 strip、段落之間統一成一個空行。最後只保證「原文每個非空白字元至少落在一個 chunk」。縮排程式碼、表格這種空白有意義的內容,這個切分器照顧不了。
  • 上面那三層,是寫這篇時第二次改對的。 第一版文件只寫兩層,說「沒超過預算的章節逐字元就是原文」。實測打臉:整段 strip 發生在更前面,兩條路徑都逃不掉。這種「幾乎正確」的敘述比明顯錯誤的更難抓,因為它讀起來很合理,而且測試如果先把空白洗掉就永遠測不出來(原本的覆蓋率測試正是這樣寫的)。

下一篇

切分好、算完向量、schema 也定了,但目前為止一次都還沒有真的檢索過。Day 13 要把東西真的送進 Azure AI Search,然後處理「怎麼找回來」——vector search、keyword search、hybrid 與 semantic ranker 的取捨,以及那個從 Day 11 就掛著的懸案:semantic ranker 到底能不能在 free tier 用(官方文件自相矛盾,那天實測——單一服務探測成功,但文件衝突本身仍未解決)。

完整程式碼在 day-12 tag,CI 綠。

用到的 Azure 服務:Azure OpenAI embeddings(text-embedding-3-small,只用來量字元與 token 的比例,純 token 計費)。本篇未建立任何計費資源——Azure AI Search 要到 Day 13 才以 ephemeral session 建立。


本文由作者規劃與撰寫,AI(Claude)協助草稿整理與程式碼驗證;技術內容與觀點由作者確認並負責。


上一篇
Day 11:RAG 是兩條 pipeline,不是一個功能——Backend 工程師該怎麼理解檢索增強生成
系列文
Backend 工程師的 Azure GenAI 實戰12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言